Skip to content

feat: AST-based extraction with runtime key parity - #5

Merged
Peyton-Spencer merged 1 commit into
mainfrom
feat/ast-extraction
Jul 19, 2026
Merged

feat: AST-based extraction with runtime key parity#5
Peyton-Spencer merged 1 commit into
mainfrom
feat/ast-extraction

Conversation

@Peyton-Spencer

Copy link
Copy Markdown
Contributor

Replaces the regex-based string extractor with an AST pass (@babel/parser, TS+JSX plugins, hand-rolled recursive visitor — no @babel/traverse) and fixes the runtime so extraction keys and runtime lookup keys are byte-identical.

The parity contract

A <T> key is built the same way on both sides:

  1. JSX text is whitespace-collapsed exactly like dom-expressions' trimWhitespace (the transform babel-preset-solid applies at compile time): \r removed; if the text contains a newline — continuation lines lose leading whitespace, whitespace-only lines are dropped, lines join with a single space; finally all whitespace runs collapse to one space. HTML entities are decoded after collapsing (extractor ships numeric + common named entity decoding).
  2. Static literal expressions ({"text"}, {5}, {`text`}, {"a" + "b"}, {-5}) are inlined by the compiler and merge into the surrounding text — extraction folds them into the key the same way.
  3. Element children (<Var>, <Num>, <Currency>, <DateTime>, arbitrary elements/components) and dynamic expressions the compiler wraps in a memo (calls, member access, tagged templates, …) become ordered {0}, {1}, … placeholders, in document order.
  4. {null} / {undefined} / boolean literals render nothing and contribute nothing.
  5. Fragments are processed inline. Adjacent text pieces concatenate with no inserted whitespace.
  6. The key is the template with leading/trailing whitespace trimmed; the runtime trims edges before lookup and restores them around the translation (edge whitespace is layout, not copy).
  7. id/context are read via AST — any quote style, expression-container strings, attributes containing > all work.

Expressions whose runtime type cannot be known statically (bare identifiers, identifier-only conditionals, interpolated template literals) are inlined raw by the compiler — a string value would merge into the runtime key while an element would become a slot — so the whole <T> is skipped with a file:line warning telling the author to wrap the value in <Var>.

Defect classes fixed (regex extractor vs runtime)

  • (a) apostrophes/escapes/mixed quotes in msg() and <T> text were silently skipped
  • (b) msg(`template literal`) skipped
  • (c) <T>Hello {name()}</T> extracted the literal {name()} text instead of Hello {0}
  • (d) <T>Click <a …>here</a></T> extracted raw HTML instead of Click {0}
  • (e) multiline <T> kept raw newlines/indentation while the compiler collapses whitespace
  • (f) attributes containing > (e.g. params={{ ok: a > b }}) corrupted the match
  • (g) self-closing <T id="k"/> skipped
  • (h) <Plural> forms never extracted
  • (j) matches inside comments and string literals

Runtime changes (required for parity)

  • <T> no longer uses resolveChildren(). Solid's children() helper resolves the compiler's memo wrappers and <Var> thunks to their values, so a dynamic string child collapsed into literal text and the {0} key was never built — placeholder translations were dead on arrival. <T> now reads raw children (flattening arrays only): strings/numbers are text, null/undefined/booleans are skipped, everything else (functions, elements) is an ordered slot. Reactivity improves: slot functions are passed through to Solid's insert for fine-grained updates.
  • Edge whitespace is excluded from the lookup key and restored around the translation.
  • <Plural> now consults the dictionary: a selected string form is translated with the source string as the key and supports an {n} placeholder interpolated with the count (other="{n} items"). Non-string forms render as-is.

Warnings

extractStringsFromSource(code, filePath, warnings?) takes an optional collector (return shape unchanged); the CLI and Vite plugin print each file:line — message. Covered: dynamic msg() args, spread props on <T>, dynamic id/context, statically-unknowable <T> children, non-literal <Plural> forms, unparseable files.

Verification

  • tests/runtime-parity.test.ts: every fixture is compiled with the real Solid compiler (babel-preset-solid, dom-expressions DOM output), rendered into happy-dom with a dictionary keyed by whatever extractStringsFromSource produced for the same source, asserting the translated text appears — covering defects a–g, entity decoding, static-literal merging, slot reordering, edge whitespace, <Plural> selection/interpolation, no-provider passthrough, and locale switching.
  • All prior extract/msg tests ported (two used syntactically invalid snippets only a regex could scan; now valid modules).
  • 84 tests pass; bun run build and bunx tsc --noEmit clean.

Notes / deviations

  • @babel/parser added as a real dependency and bundled into dist/cli.js / dist/vite.js via tsup noExternal (runtime dist/index.js untouched — 0 bytes of Babel).
  • babel-preset-solid + @babel/core added as devDependencies (integration tests compile fixtures with the real compiler).
  • Test script is now bun test --conditions=browser (solid-js otherwise resolves to its server build under Bun and DOM rendering is impossible); CI workflows switched from bun test to bun run test accordingly.
  • Fixed a pre-existing duplicate-shebang bug: src/cli.ts had its own #! line on top of the tsup banner, making the published dist/cli.js a syntax error when executed with node directly.

🤖 Generated with Claude Code

Replace the regex string extractor with an @babel/parser AST pass and fix
the runtime <T>/<Plural> key building so extracted keys and runtime lookup
keys are byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Peyton-Spencer
Peyton-Spencer merged commit 25ffee7 into main Jul 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant